Skip to content

feat(core): per-dimension identity normalization#13

Merged
rodrigobnogueira merged 1 commit into
mainfrom
feat/identity-normalization
Jul 20, 2026
Merged

feat(core): per-dimension identity normalization#13
rodrigobnogueira merged 1 commit into
mainfrom
feat/identity-normalization

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

What & why

Closes a real lockout bypass: with a case-insensitive login keyed on username/email, Alice, alice, and alice hash to three different counters — so an attacker stays under the limit by varying case or whitespace. django-axes centralizes this (AXES_USERNAME_CALLABLE); authlock's docs only warned about it. Now it's a configurable control.

new LockoutManager({
  parameters: [['username'], ['ip']],
  normalize: { username: (v) => v.trim().toLowerCase() }, // ip left verbatim
  // ...
});

How

  • Normalize type + optional normalize? on LockoutPolicy — a per-dimension map.
  • Applied inside deriveKeys (before the SHA-256), so it covers check / record / reset uniformly — a locked Alice is seen as locked under alice, and reset({ username: 'BOB' }) unlocks bob.
  • Validated at construction (each entry must be a function — fail loud at startup, never mid-auth).
  • Adapter needs no change (LockoutModuleOptions extends LockoutManagerOptions); added a test proving the option threads through forRoot.

Tests / gates

  • 100% core coverage (93 core tests); 13 adapter tests; complexity ≤15; core typecheck clean; website builds.
  • Hand-verified the normalizer-application mutant is killed (bypassing the normalizer fails 3 key-derivation tests).
  • Fully backward compatible — omit normalize and behaviour is identical.

Part of a 0.4.0 line (normalization + resetAll + audit-trail docs); no version bump in this PR — the CHANGELOG Unreleased section accumulates until the release.

A case-insensitive login keyed on an un-normalized dimension is a lockout
BYPASS: `Alice`, `alice`, and `alice ` hash to three different counters, so
an attacker exceeds the limit by varying case or whitespace. The docs already
warned about this; now the library provides the fix.

- interfaces.ts: `Normalize` type + optional `normalize?` on LockoutPolicy — a
  per-dimension `Record<dimension, (value) => string>` (mirrors django-axes's
  per-field username callable; you normalize `username`, not `ip`).
- key.ts: apply the normalizer inside deriveKeys, before hashing — so it covers
  check / record / reset uniformly (all route through deriveKeys).
- manager.ts: thread it through; validate at construction (each entry must be a
  function — fail loud, not on the hot auth path).
- Tests: key.spec (case/whitespace variants collapse to one key; only listed
  dimensions normalized; combination-key per-dimension) + manager.spec
  (end-to-end counter collapse; reset by any spelling; non-function rejected)
  + adapter (normalize threads through forRoot). 100% core coverage; the
  normalizer-application mutant is test-killed (hand-verified).
- Docs: upgraded the existing "normalize it yourself" security note to the
  configurable option + the api-reference policy table. Backward compatible.
@github-actions

Copy link
Copy Markdown

📊 Coverage Coverage Report

Metric PR Base Diff
Statements ████████████████████ 927/927 (100%) 897/897 (100%) ⚪ 0%
Branches ████████████████████ 159/159 (100%) 151/151 (100%) ⚪ 0%
Functions ████████████████████ 51/51 (100%) 50/50 (100%) ⚪ 0%
Lines ████████████████████ 927/927 (100%) 897/897 (100%) ⚪ 0%
🧾 Changed files
File Statements Branches Diff
core/key.ts ████████████████████ 57/57 (100%) 10/10 (100%) ⚪ 0%
core/manager.ts ████████████████████ 356/356 (100%) 92/92 (100%) ⚪ 0%

Updated for ec7f238 | Compared against base branch

@github-actions

Copy link
Copy Markdown

⏱️ Performance Report

✅ Tests 93 passed, 0 failed, 0 skipped
🧪 Suites 22
⏱️ Total step time 25.00s 🔴 +1.00s
⚙️ Test execution 19.66s 🔴 +589ms
🐢 Slowest test suites
# Suite Tests Duration vs Base
1 LockoutStore contract — postgres (pglite) 5 9.74s 🔴 +261ms
2 LockoutStore contract — sqlite (better-sqlite3) 5 21ms ⚪ ~0
3 MysqlLockoutStore (real drizzle-mysql2 builder, fake client) 5 10ms ⚪ ~0
4 @authlock/core acceptance (framework-neutral login flow) 2 4ms ⚪ ~0
5 lockout table schema parity 4 2ms ⚪ ~0
6 deriveKeys 13 2ms ⚪ ~0
7 LockoutManager — configuration validation 8 2ms ⚪ ~0
8 LockoutStore contract — in-memory 5 1ms ⚪ ~0
9 InMemoryLockoutStore 6 1ms ⚪ ~0
10 LockoutManager — basic lifecycle 4 1ms ⚪ ~0
🐌 Slowest individual tests
# Suite Test Duration vs Base
1 LockoutStore contract — postgres (pglite) LockoutStore contract — postgres (pglite) creates, increments (advancing lastFailureAt), then resets after the window 2.50s 🔴 +75ms
2 LockoutStore contract — postgres (pglite) LockoutStore contract — postgres (pglite) clears a single key 1.83s 🔴 +79ms
3 LockoutStore contract — postgres (pglite) LockoutStore contract — postgres (pglite) counts concurrent increments exactly — no lost updates 1.82s ⚪ ~0
4 LockoutStore contract — postgres (pglite) LockoutStore contract — postgres (pglite) reads a stored record, or null when the key is unknown 1.81s 🔴 +58ms
5 LockoutStore contract — postgres (pglite) LockoutStore contract — postgres (pglite) clearExpired removes only records older than the cutoff and counts them 1.78s ⚪ ~0
6 LockoutStore contract — sqlite (better-sqlite3) LockoutStore contract — sqlite (better-sqlite3) counts concurrent increments exactly — no lost updates 9ms ⚪ ~0
7 MysqlLockoutStore (real drizzle-mysql2 builder, fake client) MysqlLockoutStore (real drizzle-mysql2 builder, fake client) increment issues an atomic windowed upsert then re-reads the row 7ms ⚪ ~0
8 LockoutStore contract — sqlite (better-sqlite3) LockoutStore contract — sqlite (better-sqlite3) creates, increments (advancing lastFailureAt), then resets after the window 6ms ⚪ ~0
9 @authlock/core acceptance (framework-neutral login flow) @authlock/core acceptance (framework-neutral login flow) locks a brute-force loop after the limit, then recovers after cooloff 2ms ⚪ ~0
10 LockoutStore contract — sqlite (better-sqlite3) LockoutStore contract — sqlite (better-sqlite3) reads a stored record, or null when the key is unknown 2ms ⚪ ~0
11 LockoutStore contract — sqlite (better-sqlite3) LockoutStore contract — sqlite (better-sqlite3) clearExpired removes only records older than the cutoff and counts them 2ms ⚪ ~0
12 @authlock/core acceptance (framework-neutral login flow) @authlock/core acceptance (framework-neutral login flow) exposes a semver-shaped version 1ms ⚪ ~0
13 MysqlLockoutStore (real drizzle-mysql2 builder, fake client) MysqlLockoutStore (real drizzle-mysql2 builder, fake client) get returns the mapped record when present 1ms ⚪ ~0
14 MysqlLockoutStore (real drizzle-mysql2 builder, fake client) MysqlLockoutStore (real drizzle-mysql2 builder, fake client) clear deletes by key 1ms ⚪ ~0
15 lockout table schema parity lockout table schema parity postgres table has the expected shape 1ms ⚪ ~0

Updated for ec7f238 | Compared against base branch

@github-actions

Copy link
Copy Markdown

🧠 Cognitive Complexity Report

Metric PR Base Diff
Total complexity 71 64 🔴 +7
Max function complexity 11 11 ⚪ 0
Functions measured 26 25 ⚪ +1
🧩 Most complex functions
Function Location Complexity Status
deriveKeys packages/core/key.ts:32 11 🟢 OK
validateTiers packages/core/manager.ts:28 11 🟢 OK
validateNormalize packages/core/manager.ts:72 4 🟢 OK
cooloffFor packages/core/policy.ts:10 4 🟢 OK
evaluateRecord packages/core/policy.ts:78 4 🟢 OK
mostRestrictive packages/core/manager.ts:105 3 🟢 OK
LockoutManager.clearKeys packages/core/manager.ts:243 3 🟢 OK
LockoutManager.applyFailure packages/core/manager.ts:264 3 🟢 OK
effectiveWindowMs packages/core/policy.ts:33 3 🟢 OK
InMemoryLockoutStore.clearExpired packages/core/store/in-memory.ts:45 3 🟢 OK
🧾 Changed files
File Total Max Functions Total diff
core/key.ts 11 11 1 🔴 +3
core/manager.ts 42 11 17 🔴 +4

🧭 Cognitive complexity is reported as a review signal, not a merge gate. Prefer small, intention-revealing refactors when complexity rises.


Updated for ec7f238 | Compared against base branch

@rodrigobnogueira
rodrigobnogueira merged commit 8f79c3d into main Jul 20, 2026
11 checks passed
@rodrigobnogueira
rodrigobnogueira deleted the feat/identity-normalization branch July 20, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant